跳转至

9 Combining Animations

Most of this book’s animations deal with user interaction. In earlier chapters, you used animation to draw the user’s attention to the desired area in your app. These animations help guide the user while at the same time adding polish and improving the app’s visual appearance.

In this chapter, you’ll build an animation to act as a reward for the user when the steeping timer ends. This animation will show liquid pouring into the view’s background and filling it up.

img

Since this is a more complex animation, you’ll build it in two parts. First, you’ll add the animation that resembles a rising liquid within a container. You’ll then use SpriteKit’s particle system to add the pouring liquid that appears to fill the container.

Building a Background Animation

Open the starter project for this chapter. You’ll see the familiar Tea Brewing from previous chapters.

The start project contains a new group called PourAnimation, which includes the TimerComplete view shown when a steeping timer finishes. To start the new animation, create a SwiftUI view file named PourAnimationView.swift in the PourAnimation folder`.

You’ll use this view to contain the new animation’s views. As with other animations, starting with a simple version and then expanding upon it to create the final animation is the easiest. At the top of the generated struct, add the following new properties:

@State var shapeTop = 900.0
let fillColor = Color(red: 0.180, green: 0.533, blue: 0.78)

This code adds a state property you’ll use to control the animation. You also define a blue color you’ll use as the liquid’s color. Update the body of the view to:

// 1
Rectangle()
  // 2
  .fill(fillColor)
  // 3
  .offset(y: shapeTop)
  // 4
  .onAppear {
    withAnimation(.linear(duration: 6.0)) {
      shapeTop = 0.0
    }
  }

Here’s what the code does:

  1. You define a Rectangle shape that you’ll replace with a more complex Shapelater.
  2. You fill the Rectangle with the blue color you defined earlier.
  3. This offsets the rectangle by the amount of shapeTop. By changing shapeTop, you can change the position of the top of the rectangle on the view.
  4. When the view appears, you use an explicit linear animation that takes six seconds to complete. SwiftUI will apply the animation when you change shapeTop to zero. The animation will then animate the movement of the Rectangle from the initial position to the top of the view.

You need to add this new view to the view that shows when the timer finishes. Open TimerComplete.swift. This view consists of a ZStack, which starts with a backgroundGradient. After the gradient and before the VStack, add the following code:

PourAnimationView()

Run the app and select any tea. Start the timer and wait for it to complete. Once the timer finishes, you’ll see the animation as the blue rectangle fills the view over six seconds, like a cup filling with liquid. Remember, you can adjust the timer length.

img

The clipped area at the bottom seems out of place. By default, SwiftUI keeps a view from entering the device’s safe area. To eliminate the bar at the bottom, you need to tell SwiftUI to allow the view to extend into that area.

In TimerComplete.swift, change the call to the view to:

PourAnimationView()
  .ignoresSafeArea(edges: [.bottom])

ignoresSafeArea(_:edges:) tells SwiftUI to allow the view to extend into part of that bottom part of the safe area.

Run the app, start a timer and let it complete. The Rectangle’s fill color now extends to the bottom of the screen.

img

Now that you’ve built the basics of the pouring animation, you’ll make the top of the rising liquid more realistic in the next section.

Making a Wave Animation

If you watch a liquid pouting into a cup, you’ll see the top of the liquid is anything but a smooth, flat surface. It makes a much more chaotic and complex flow.

While implementing actual fluid dynamics would be overkill, you can simulate a more complex shape to the pour using a sine wave. In this section, you’ll implement a custom Shape and change the top of the animation to a sine wave.

In the PourAnimation folder, create a new SwiftUI view file named WaveShape.swift. You’ll create a custom shape instead of a view, so replace the existing generated structwith:

struct WaveShape: Shape {
  func path(in rect: CGRect) -> Path {
    Path()
  }
}

A Shape returns a Path that defines the shape instead of a View. SwiftUI passes a CGRect struct as a parameter to the method. It contains the size of the container for the shape. This initial implementation only returns an empty path, but not for long.

To see your shape in the preview as you develop it, change the preview to:

WaveShape()
  .stroke(.black)
  .offset(y: 200)

This change strokes the path in black in the preview. It also uses a vertical offset, so the full path shows on the preview. Otherwise, you’d cut off the top portion when SwiftUI draws it on the view.

In previous chapters, you used sine and other trigonometric functions in animations when drawing lines at an angle. Here, you’ll use it since the top of your shape will be a sine wave.

The plot of the sine function from zero through 360 degrees looks like this:

img

It produces a perfect wave shape with the vertical axis ranging between negative one and one over the distance. Due to the definition of a sine function, the wave varies regularly over the 360 degrees that make up a single revolution of a circle. After 360 degrees, the values repeat with y taking on the same value it did at the same angle minus 360 degrees.

To implement this shape in SwiftUI, replace the closure of path(in:) with:

// 1
Path { path in
  // 2
  for x in 0 ..< Int(rect.width) {
    // 3
    let angle = Double(x) / rect.width * 360
    // 4
    let y = sin(Angle(degrees: angle).radians) * 100
    // 5
    if x == 0 {
      path.move(to: .init(x: Double(x), y: -y))
    } else {
      path.addLine(to: .init(x: Double(x), y: -y))
    }
  }
}

Here’s how this code draws the sine wave as a shape:

  1. You create an an empty Path and accept a path to manipulate in the trailing closure’s body.
  2. You iterate all x positions in the rectangle using a for-in loop. This loop ensures you perform only the necessary calculations for the shape’s size.
  3. For each x position, you calculate the angle it should reflect by dividing it by the total width of the rectangle. This result gives you the position as a fraction of the full width. You then multiply this fraction by 360, giving you the position as a degree of a full 360-degree circle.
  4. You get the sine of the angle from step three using the sin method. You convert from degrees to radians inside the function, as with other Swift trigonometric functions. Since this will provide a value between negative one and one, you multiply it by 100, increasing the wave’s size.
  5. The first time through the loop, you move the path location to the current horizontal position and the vertical position calculated in the last step. After that, you draw a line from the current position to the following path position. Since increasing values of y on a Path are downward on the view, you take the negative of y to flip positive values upward.

The preview for the shape shows you a simple sine wave:

img

To make this work in your animation, it must produce a completely closed shape like the Rectangle. You also need to let the calling view specify a position for the top of the shape. You’ll do that in the next section.

Animating the Sine Wave

Add the following new property to the top of the Shape before path(in:):

var waveTop: Double = 0.0

This property lets the calling view control the location of the sine wave. Update the code under comment five to:

// 5
if x == 0 {
  path.move(to: .init(
    x: Double(x),
    y: waveTop - y
  ))
} else {
  path.addLine(to: .init(
    x: Double(x),
    y: waveTop - y
  ))
}

This change adds the value of waveTop to the vertical position of the view. A positive value shifts the wave’s position down the shape.

To close the shape, add the following code after the for-in loop:

path.addLine(to: .init(x: rect.width, y: rect.height))
path.addLine(to: .init(x: 0, y: rect.height))
path.closeSubpath()

for-in ends with the position on the right edge of the view. So you add a line to the bottom-right of the view before adding a line to the left-bottom side of the view. You then call closeSubpath() on the path to ensure it forms a closed shape.

To better see the difference, change the preview to:

WaveShape(waveTop: 200.0)
  .fill(.black)

The shape fills in from the view’s bottom up to a point specified by waveTop. You no longer need offset(x:y:) on the shape because you can control the location with waveTop.

Go to PourAnimationView.swift and change the body to use the new shape you just implemented:

WaveShape(waveTop: shapeTop)
  .fill(fillColor)
  .onAppear {
    withAnimation(.linear(duration: 6.0)) {
      shapeTop = 0.0
    }
  }

You’ll see your new shape in the preview, but it immediately jumps to the new position without the animation. To confirm this, run the app and let a timer finish.

img

The Shape protocol supports animation but requires you to conform to the Animatable protocol. Shape already conforms to Animatable, so all you have to do is implement its requirements.

Go back to WaveShape.swift and add the following computed property after waveTop:

var animatableData: Double {
  get { waveTop }
  set { waveTop = newValue }
}

Animatable has one requirement, the animatableData property. This property provides a bridge SwiftUI understands when implementing custom animation for a shape or view.

Run the app and let a timer complete to see that the wave moves smoothly. See Chapter 6: Introduction to Custom Animations for more about the protocol.

img

This wave shape works, but it’s limited. It only produces a single shape that always looks the same. The current wave height also looks too big for the view. In the next section, you’ll let the calling view modify the wave’s shape.

Modifying the Filling View

You can change the shape of a sine wave by changing three properties: amplitude, wave length and phase.

img

Add the following new properties to WaveShape after waveTop:

var amplitude = 100.0
var wavelength = 1.0
var phase = 0.0

The amplitude determines the height of the wave. By default, the sine function’s values vary between negative one and one. You can multiply that value by another number to change the shape’s height. You already modified this in the initial shape using a fixed value of 100.00.

To implement the amplitude, change the code under comment four to:

// 4
let y = sin(Angle(degrees: angle).radians) * amplitude

This change replaces the constant 100.0 value with the new property allowing any height wave.

Right now, the shape creates a single wave filling the entire space. The wavelengthproperty lets you compress or stretch the wave.

To implement the wavelength, change the code under comment three to:

// 3
let angle = Double(x) / rect.width * wavelength * 360.0

The calculation adds a multiplication by the new wavelength parameter to the previous calculation. If this parameter is greater than one, it’ll increase the number of waves appearing on the screen since the angle will rise more quickly. Think of the parameter as defining how many complete waves will show across the view.

To shift the wave horizontally, change the starting degree. Right now, you begin the wave at zero degrees, which produces a y of zero. The phase parameter lets you shift this beginning point so the wave can start at an arbitrary point.

You must adjust the angle calculated in step three to implement the phase parameter. Change the code to:

// 3
let angle = Double(x) / rect.width * wavelength * 360.0 + phase

You calculate an angle in step three and can change this angle by adding the desired change in degrees. The phase property provides the angle where the drawn wave should begin.

These new properties help you control the parameters of the wave. Open PourAnimationView.swift and change the call to WaveShape() to:

WaveShape(
  waveTop: shapeTop,
  amplitude: 15,
  wavelength: 4,
  phase: 90
)

Run the app and let a tea timer complete. You’ll see your new animation. The wave shows more peaks and troughs with a smaller height and shifted to the right compared to before.

img

This new wave produces a more realistic fill than a flat surface, but it’s still too static. In the next section, you’ll add some motion to the wave itself.

Animating Multiple Parts of the Wave

When you added waveTop to WaveShape, you needed to implement animatableDataso SwiftUI could animate it. Therefore, you might expect to do the same for the three additional properties before you can animate them.

However, you have four properties to animate and only one property in the AnimatableData protocol. To handle these situations, SwiftUI provides the AnimatablePair struct. It lets you specify a pair of values for the animatableDataproperty. In addition, each of the two values in the struct can be animatable, meaning you can nest values to support the number of properties you need.

Open WaveShape.swift and replace the animatableData property with:

// 1
var animatableData: AnimatablePair<
  AnimatablePair<Double, Double>,
  AnimatablePair<Double, Double>
  > {
  get {
    // 2
    AnimatablePair(
      AnimatablePair(waveTop, amplitude),
      AnimatablePair(wavelength, phase)
    )
  }
  set {
    // 3
    waveTop = newValue.first.first
    amplitude = newValue.first.second
    wavelength = newValue.second.first
    phase = newValue.second.second
  }
}

Here’s how this code implements AnimatableData for your shape:

  1. You define the animatableData property to have a type of AnimatablePair<AnimatablePair<Double, Double>,AnimatablePair<Double, Double>>. To animate four Doubles, you need four values. To get those, you need two AnimatablePair structs that you wrap inside an external AnimatablePair. This struct produces an AnimatablePair whose first and second values are AnimatablePair structs whose values are both a Double.
  2. When SwiftUI requests the value for the property, you build an AnimatablePairstruct. The first value of the struct is an AnimatablePair containing the waveLength and amplitude properties in the Shape. The second AnimatablePair struct consists of the wavelength and phase properties from the Shape.
  3. When SwiftUI provides new values, you set the properties in the same order as you send them in step two. Notice the use of newValue.first to access the elements wrapped in the first AnimatablePair and newValue.second to access the second pair.

This diagram shows how the properties map through the AnimatablePair type of animatableData.

img

For more on AnimatablePair, see Chapter 7: Complex Custom Animations.

With this change, you can animate all properties of the WaveShape. To put this to use, open PourAnimationView.swift and add a new computed property to the top of the view:

var waveHeight: Double {
  min(shapeTop / 10.0, 20.0)
}

This property calculates a wave height equal to the top of the shape divided by ten. The value of waveHeight starts at 20 and decreases as shapeTop decreases. min caps the value at 20, so the height isn’t too large at the beginning of the animation.

Update amplitude in WaveShape to:

amplitude: waveHeight,

Using the new computed property for the shape’s amplitude produces a larger wave that decreases as the animation nears the end. Run the app and let a timer complete to see the wave’s height decrease.

img

Since pouring a liquid produces a chaotic movement, you can make the animation more realistic by adding more movement to the wave. Shifting the phase for the WaveShape will do just that.

Open PourAnimationView.swift and add the following new property after shapeTop:

@State var wavePhase = 90.0

Change phase to the WaveShape view to read:

phase: wavePhase

This parameter has the shape use the new state property. Add the following code at the start of onAppear(perform:):

withAnimation(
  .easeInOut(duration: 0.5)
  .repeatForever()
) {
  wavePhase = -90.0
}

You do the same for the phase as you did when you changed shapeTop to animate a rising shape. Changing the phase adds a back-and-forth movement to the water in the view as it rises. You create an ease-in-out animation lasting one-half second. repeatForever(autoreverses:) tells SwiftUI to repeat the animation forever. Since autoreverses defaults to true, the animation will reverse before repeating.

Run the app and let a tea timer complete. You’ll see the new motion in the animation.

img

Now that your animation resembles water rising in a cup, you’ll add another wave in the next section to give the animation more complexity.

Adding Multiple Waves

While your wave resembles rising water, you can enhance the effect by adding more waves offset from the current wave.

Open PourAnimationView.swift and add the following new property after wavePhase:

@State var wavePhase2 = 0.0

Also, add a new color definition after fillColor:

let waveColor2 = Color(red: 0.129, green: 0.345, blue: 0.659)

Wrap the current WaveShape inside a ZStack by Command-clicking WaveShape and selecting Embed in ZStack from the menu. Keep .fill(fillColor) with WaveShapeand move onAppear(perform:) to the ZStack. Add the following code inside the new ZStack and before the existing WaveShape:

WaveShape(
  waveTop: shapeTop,
  amplitude: waveHeight * 1.2,
  wavelength: 5,
  phase: wavePhase2
)
.fill(waveColor2)

This code produces a wave shape based on the existing one. It’s 1.2 times higher and shows five complete waves across the view. You also use the newly added wavePhase2as the phase.

To animate this property of the new shape, add the following code to the onAppear(perform:) after the withAnimation(_:_:) that changes wavePhase:

withAnimation(
  .easeInOut(duration: 0.3)
  .repeatForever()
) {
  wavePhase2 = 270.0
}

Run the app, and you’ll see a second, darker blue wave behind the existing one. It appears behind the first since you placed it first in the ZStack.

img

Now that you have a nice animation of the view filling, the only thing missing is what’s filling it. You’ll start adding the pour in the next section.

Animation With Particles

The most efficient way to create a pour animation, the animation of a liquid acting under gravity, is to use a particle system. A particle system is a group of points that change under rules that affect their behavior and appearance. They work well to create effects such as smoke, rain, confetti and fireworks.

It’s possible to write one natively in SwiftUI, but there’s no need in this case since Apple provides particle systems in several libraries. In this section, you’ll begin implementing a particle system in SceneKit and SpriteKit to add to your animation. SwiftUI supports SceneKit through the SceneView view, displaying SceneKit content.

To create the pour animation, you must build up several elements and combine them into a SceneKit scene. You’ll start with the particle emitter.

Creating a Particle Emitter

Under the PourAnimation folder, create a new SpriteKit Particle File. For Particle template, select Rain and click Next. Name it PourParticle. The preview will show the new particle file, which resembles a light rain:

img

Select the Attributes Inspector for the particle file and change the following values:

  • Change Texture to dropshape to select a drop shaped image for the particle.
  • Change Emitter ▸ Birthrate to 600 to increase the number of particles.
  • Change Position Range ▸ X to 55 as a lower number reduces the size of the space where the emitter creates particles.
  • Change Angle ▸ Start to 270 to produce particles with a veritcal downward motion.
  • Change Speed ▸ Start to 600 to speed up the particle motion.

Your final particle will look like this:

img

Click the circle next to Color Ramp. This selection will bring up a color picker. Select the second tab, which shows a slider option. Change the slider to RGB Sliders and change the Hex Value field in the bottom right to #1898FF.

The particles take on a blue color that may be hard to see on the default black background. You can change the Custom color to white to help them stand out.

img

With your completed particle emitter, you can create a SceneKit scene to hold the emitter. You’ll begin that in the next section.

Building a SceneKit Scene

First, you need a SwiftUI view that’ll display your SceneKit scene. Inside the PourAnimation folder, create a new SwiftUI view file named PourSceneView. At the top of the new file, add a second import:

import SpriteKit

You import SpriteKit because it includes both SpriteKit and SceneKit, which you’ll use in this view.

First, you create a SKScene that defines the scene. At the top of the file before PourSceneView, add:

class PouringLiquidScene: SKScene {
  static let shared = PouringLiquidScene()
}

This bare-bones implementation contains only a single static property that creates an instance of itself. You’ll use this class to define the view-independent properties for the scene. Add the following property to the class after the static property:

let dropEmitter = SKEmitterNode(fileNamed: "PourParticle")

SKEmitterNode loads the particle emitter you created in the last section. Notice you don’t need to specify the file’s extension,

You set up a SKScene inside didMove(to:). The framework calls the method when the scene is presented to the view. Add the following code to your class:

override func didMove(to view: SKView) {
  // 1
  self.backgroundColor = .clear
  // 2
  if let dropEmitter,
     !self.children.contains(dropEmitter){
    self.addChild(dropEmitter)
  }

  // 3
  dropEmitter?.position.x = 100
  dropEmitter?.position.y = self.frame.maxY
}

Here’s the setup for SKScene:

  1. You set the scene’s background color to clear. This change lets anything behind the scene, like other views, show through.
  2. You attempt to unwrap dropEmitter. If successful, you then ensure the emitter isn’t already present in the scene before adding it as a child of the current scene. Unwrapping dropEmitter can only fail if PourParticle.sks (the particle file you created) is missing or corrupt.
  3. Particles from a SKEmitterNode appear at the location you provide to the position property. You set the horizontal position 100 points from the left edge. Unlike most SwiftUI-related coordinates, in a SKScene, the y value increases going upward in the view. Therefore, you set the y position to self.frame.maxY, placing it at the top of the view.

With that class in place, you can now use it in your SwiftUI view. Add the following computed property to PourSceneView:

var pouringScene: SKScene {
  // 1
  let scene = PouringLiquidScene.shared
  // 2
  scene.size = UIScreen.main.bounds.size
  scene.scaleMode = .fill
  // 3
  return scene
}

This property produces the SKScene you’ll use inside your SwiftUI view by:

  1. This gets the shared instance of the class through the shared property.
  2. You set the size to match the size of the main screen, so the SKScene takes up the full view. You also set the scale mode to .fill to fill the entire view.
  3. You return this modified view.

Finally, change the body of the view to:

SpriteView(
  scene: pouringScene,
  options: [.allowsTransparency]
)

You call SpriteView, passing in the scene’s name from your pouringScene computed property. You pass .allowsTransparency to the options argument. Otherwise, the views below this in the stack wouldn’t show through SpriteView and your self.backgroundColor = .clear setting in didMove(to:) would be ignored.

Check out the preview, where you can see the pouring liquid you created:

img

With the SceneKit view added to a SwiftUI view, you can quickly finish the animation in the next section by combining the two.

Finishing the Animation

Open PourAnimationView.swift and add the following state property after wavePhase2:

@State var showPour = true

This property controls showing the pouring animation. Add the following code before the first WaveShape() inside the ZStack:

if showPour {
  PourSceneView()
}

Run the app, select any tea and let the timer complete. You’ll see the new particle animation added to the view.

img

Since you added it before the WaveShape views, the pouring particle appears behind the rising liquid. However, the rising animation begins before the particles reach the bottom of the view, which spoils the illusion that the pouring causes the liquid to rise. To fix this, you can add a short delay before the liquid begins to rise. Inside onAppear(perform:), find the last withAnimation(_:_:), which changes shapeTop, and change it to:

withAnimation(
  .linear(duration: 6.0)
  .delay(1)
) {
  shapeTop = 0.0
}

You use the delay(_:) modifier on the linear animation with a value of 1 which delays for one second before changing shapeTop to zero and beginning the rising liquid animation.

For performance reasons, you don’t want the particle emitter to keep running once the animation completes, which occurs when shapeTop reaches zero. If you directly compared shapeTop to zero, the explicit animation on shapeTop would cause SwiftUI to apply a transition to the view removal, fading it away. Instead, add the following code to the end of onAppear(perform:):

DispatchQueue.main.asyncAfter(deadline: .now() + 7.0) {
  showPour = false
}

This code sets showPour to false after seven seconds, hiding the view. You get seven seconds from the one-second delay above plus the six seconds length of the animation. Run the app and let a tea timer complete to see your finished animation.

img

Key Points

  • You can use animations to draw the user’s attention to an element and add a nice visual to reinforce the user’s action.
  • You can combine multiple animations to produce a finished visual effect for complex animations.
  • The SwiftUI animation system is robust and capable, but you can leverage other Apple frameworks when creating animations. SwiftUI lets you efficiently use them in your SwiftUI project.
  • SceneKit includes a particle system that works well to produce smoke, rain, confetti and fire.

Where to Go From Here?

Chapter 6: Introduction to Custom Animations and Chapter 7: Complex Custom Animations of this book go into more detail on using the AnimatableData and AnimatablePair protocols.

For more about SceneKit, see SceneKit 3D Programming for iOS: Getting Started.

You can read more about the SceneKit particle system in SceneKit Tutorial with Swift Part 5: Particle Systems.